/*
 * Copyright (C) 2012 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package com.android.utils;

import android.content.Context;
import android.os.Bundle;
import android.provider.Settings;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import com.android.utils.PerformActionUtils;

import java.util.HashSet;

/**
 * Utility class for sending commands to ChromeVox.
 */
public class WebInterfaceUtils {
    /**
     * If injection of accessibility enhancing JavaScript screen-reader is
     * enabled.
     * <p>
     * This property represents a boolean value encoded as an integer (1 is
     * true, 0 is false).
     */
    private static final String ACCESSIBILITY_SCRIPT_INJECTION = "accessibility_script_injection";

    private static final String ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES =
            "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES";

    /**
     * Direction constant for forward movement within a page.
     */
    public static final int DIRECTION_FORWARD = 1;

    /**
     * Direction constant for backward movement within a page.
     */
    public static final int DIRECTION_BACKWARD = -1;

    /**
     * Action argument to use with
     * {@link #performSpecialAction(AccessibilityNodeInfoCompat, int)} to
     * instruct ChromeVox to read the currently focused element within the node.
     * within the page.
     */
    public static final int ACTION_READ_CURRENT_HTML_ELEMENT = -1;

    /**
     * Action argument to use with
     * {@link #performSpecialAction(AccessibilityNodeInfoCompat, int)} to
     * instruct ChromeVox to read the title of the page within the node.
     */
    public static final int ACTION_READ_PAGE_TITLE_ELEMENT = -2;

    /**
     * Action argument to use with
     * {@link #performSpecialAction(AccessibilityNodeInfoCompat, int)} to
     * instruct ChromeVox to stop all speech and automatic actions.
     */
    public static final int ACTION_STOP_SPEECH = -3;

    /**
     * Action argument to use with
     * {@link #performSpecialAction(AccessibilityNodeInfoCompat, int, int)} to
     * instruct ChromeVox to move into or out of the special content navigation
     * mode.
     * <p>
     * Using this constant also requires specifying a direction.
     * {@link #DIRECTION_FORWARD} indicates ChromeVox should move into this
     * content navigation mode, {@link #DIRECTION_BACKWARD} indicates ChromeVox
     * should move out of this mode.
     */
    private static final int ACTION_TOGGLE_SPECIAL_CONTENT = -4;

    /**
     * Action argument to use with
     * {@link #performSpecialAction(AccessibilityNodeInfoCompat, int, int)} to
     * instruct ChromeVox to move into or out of the incremental search mode.
     * <p>
     * Using this constant does not require a direction as it only toggles
     * the state.
     */
    public static final int ACTION_TOGGLE_INCREMENTAL_SEARCH = -5;

    /**
     * HTML element argument to use with
     * {@link #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat,
     * int, String)} to instruct ChromeVox to move to the next or previous page
     * section.
     */
    public static final String HTML_ELEMENT_MOVE_BY_SECTION = "SECTION";

    /**
     * HTML element argument to use with
     * {@link #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat,
     * int, String)} to instruct ChromeVox to move to the next or previous link.
     */
    public static final String HTML_ELEMENT_MOVE_BY_LINK = "LINK";

    /**
     * HTML element argument to use with
     * {@link #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat,
     * int, String)} to instruct ChromeVox to move to the next or previous list.
     */
    public static final String HTML_ELEMENT_MOVE_BY_LIST = "LIST";

    /**
     * HTML element argument to use with
     * {@link #performNavigationToHtmlElementAction(AccessibilityNodeInfoCompat,
     * int, String)} to instruct ChromeVox to move to the next or previous
     * control.
     */
    public static final String HTML_ELEMENT_MOVE_BY_CONTROL = "CONTROL";

    /**
     * Sends an instruction to ChromeVox to read the specified HTML element in
     * the given direction within a node.
     * <p>
     * WARNING: Calling this method with a source node of
     * {@link android.webkit.WebView} has the side effect of closing the IME
     * if currently displayed.
     *
     * @param node The node containing web content with ChromeVox to which the
     *            message should be sent
     * @param direction {@link #DIRECTION_FORWARD} or
     *            {@link #DIRECTION_BACKWARD}
     * @param htmlElement The HTML tag to send
     * @return {@code true} if the action was performed, {@code false}
     *         otherwise.
     */
    public static boolean performNavigationToHtmlElementAction(
            AccessibilityNodeInfoCompat node, int direction, String htmlElement) {
        final int action = (direction == DIRECTION_FORWARD)
                ? AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT
                : AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT;
        final Bundle args = new Bundle();
        args.putString(
                AccessibilityNodeInfoCompat.ACTION_ARGUMENT_HTML_ELEMENT_STRING, htmlElement);
        return PerformActionUtils.performAction(node, action, args);
    }

    public static String[] getSupportedHtmlElements(AccessibilityNodeInfoCompat node) {
        HashSet<AccessibilityNodeInfoCompat> visitedNodes =
            new HashSet<AccessibilityNodeInfoCompat>();

        while (node != null) {
            if (visitedNodes.contains(node)) {
                return null;
            }

            visitedNodes.add(node);

            Bundle bundle = node.getExtras();
            CharSequence supportedHtmlElements =
                bundle.getCharSequence(ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES);

            if (supportedHtmlElements != null) {
                return supportedHtmlElements.toString().split(",");
            }

            node = node.getParent();
        }

        return null;
    }

    /**
     * Sends an instruction to ChromeVox to navigate by DOM object in
     * the given direction within a node.
     *
     * @param node The node containing web content with ChromeVox to which the
     *            message should be sent
     * @param direction {@link #DIRECTION_FORWARD} or
     *            {@link #DIRECTION_BACKWARD}
     * @return {@code true} if the action was performed, {@code false}
     *         otherwise.
     */
    public static boolean performNavigationByDOMObject(
            AccessibilityNodeInfoCompat node, int direction) {
        final int action = (direction == DIRECTION_FORWARD)
                ? AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT
                : AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT;
        return PerformActionUtils.performAction(node, action);
    }

    /**
     * Sends an instruction to ChromeVox to move within a page at a specified
     * granularity in a given direction.
     * <p>
     * WARNING: Calling this method with a source node of
     * {@link android.webkit.WebView} has the side effect of closing the IME
     * if currently displayed.
     *
     * @param node The node containing web content with ChromeVox to which the
     *            message should be sent
     * @param direction {@link #DIRECTION_FORWARD} or
     *            {@link #DIRECTION_BACKWARD}
     * @param granularity The granularity with which to move or a special case argument.
     * @return {@code true} if the action was performed, {@code false} otherwise.
     */
    public static boolean performNavigationAtGranularityAction(
            AccessibilityNodeInfoCompat node, int direction, int granularity) {
        final int action = (direction == DIRECTION_FORWARD)
                ? AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
                : AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY;
        final Bundle args = new Bundle();
        args.putInt(
                AccessibilityNodeInfoCompat.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity);
        return PerformActionUtils.performAction(node, action, args);
    }

    /**
     * Sends instruction to ChromeVox to perform one of the special actions
     * defined by the ACTION constants in this class.
     * <p>
     * WARNING: Calling this method with a source node of
     * {@link android.webkit.WebView} has the side effect of closing the IME if
     * currently displayed.
     *
     * @param node The node containing web content with ChromeVox to which the
     *            message should be sent
     * @param action The ACTION constant in this class match the special action
     *            that ChromeVox should perform.
     * @return {@code true} if the action was performed, {@code false}
     *         otherwise.
     */
    public static boolean performSpecialAction(AccessibilityNodeInfoCompat node, int action) {
        return performSpecialAction(node, action, DIRECTION_FORWARD);
    }

    /**
     * Sends instruction to ChromeVox to perform one of the special actions
     * defined by the ACTION constants in this class.
     * <p>
     * WARNING: Calling this method with a source node of
     * {@link android.webkit.WebView} has the side effect of closing the IME if
     * currently displayed.
     *
     * @param node The node containing web content with ChromeVox to which the
     *            message should be sent
     * @param action The ACTION constant in this class match the special action
     *            that ChromeVox should perform.
     * @param direction The DIRECTION constant in this class to add as an extra
     *            argument to the special action.
     * @return {@code true} if the action was performed, {@code false}
     *         otherwise.
     */
    private static boolean performSpecialAction(
            AccessibilityNodeInfoCompat node, int action, int direction) {
        /*
         * We use performNavigationAtGranularity to communicate with ChromeVox
         * for these actions because it is side-effect-free. If we use
         * performNavigationToHtmlElementAction and ChromeVox isn't injected,
         * we'll actually move selection within the fallback implementation. We
         * use the granularity field to hold a value that ChromeVox interprets
         * as a special command.
         */
        return performNavigationAtGranularityAction(node, direction, action /* fake granularity */);
    }

    /**
     * Sends a message to ChromeVox indicating that it should enter or exit
     * special content navigation. This is applicable for things like tables and
     * math expressions.
     * <p>
     * NOTE: further navigation should occur at the default movement
     * granularity.
     *
     * @param node The node representing the web content
     * @param enabled Whether this mode should be entered or exited
     * @return {@code true} if the action was performed, {@code false}
     *         otherwise.
     */
    public static boolean setSpecialContentModeEnabled(
            AccessibilityNodeInfoCompat node, boolean enabled) {
        final int direction = (enabled) ? DIRECTION_FORWARD : DIRECTION_BACKWARD;
        return performSpecialAction(node, ACTION_TOGGLE_SPECIAL_CONTENT, direction);
    }

    /**
     * Determines whether or not the given node contains web content.
     *
     * @param node The node to evaluate
     * @return {@code true} if the node contains web content, {@code false} otherwise
     */
    public static boolean supportsWebActions(AccessibilityNodeInfoCompat node) {
        return AccessibilityNodeInfoUtils.supportsAnyAction(node,
                AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT,
                AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT);
    }

    /**
     * Determines whether or not the given node contains native web content (and not ChromeVox).
     *
     * @param node The node to evaluate
     * @return {@code true} if the node contains native web content, {@code false} otherwise
     */
    public static boolean hasNativeWebContent(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return false;
        }

        if (!supportsWebActions(node)) {
            return false;
        }

        // ChromeVox does not have sub elements, so if the parent element also has web content
        // this cannot be ChromeVox.
        AccessibilityNodeInfoCompat parent = node.getParent();
        if (supportsWebActions(parent)) {
            if (parent != null) {
                parent.recycle();
            }
            return true;
        }

        if (parent != null) {
            parent.recycle();
        }

        // ChromeVox never has child elements
        return node.getChildCount() > 0;
    }

    /**
     * Determines whether or not the given node contains ChromeVox content.
     *
     * @param node The node to evaluate
     * @return {@code true} if the node contains ChromeVox content, {@code false} otherwise
     */
    public static boolean hasLegacyWebContent(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return false;
        }

        // TODO: Need better checking for native versus legacy web content.
        // Right now Firefox is accidentally treated as legacy web content using the current
        // detection routines; the `isNodeFromFirefox` check blacklists any Firefox that supports
        // the native web actions from being treated as "legacy" content.
        // Once we have resolved this issue, remove the `isNodeFromFirefox` disjunct from the check.
        if (!supportsWebActions(node) || isNodeFromFirefox(node)) {
            return false;
        }

        // ChromeVox does not have sub elements, so if the parent element also has web content
        // this cannot be ChromeVox.
        AccessibilityNodeInfoCompat parent = node.getParent();
        if (supportsWebActions(parent)) {
            if (parent != null) {
                parent.recycle();
            }

            return false;
        }

        if (parent != null) {
            parent.recycle();
        }

        // ChromeVox never has child elements
        return node.getChildCount() == 0;
    }

    /**
     * @return {@code true} if the user has explicitly enabled injection of
     *         accessibility scripts into web content.
     */
    public static boolean isScriptInjectionEnabled(Context context) {
        final int injectionSetting = Settings.Secure.getInt(
                context.getContentResolver(), ACCESSIBILITY_SCRIPT_INJECTION, 0);
        return (injectionSetting == 1);
    }

    /**
     * Returns whether the given node has navigable web content, either legacy (ChromeVox) or native
     * web content.
     *
     * @param context The parent context.
     * @param node The node to check for web content.
     * @return Whether the given node has navigable web content.
     */
    public static boolean hasNavigableWebContent(
            Context context, AccessibilityNodeInfoCompat node) {
        return (supportsWebActions(node) && isScriptInjectionEnabled(context))
                || hasNativeWebContent(node);
    }

    /**
     * Check if node is web container
     */
    public static boolean isWebContainer(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return false;
        }
        return hasNativeWebContent(node) || isNodeFromFirefox(node);
    }

    private static boolean isNodeFromFirefox(AccessibilityNodeInfoCompat node) {
        if (node == null) {
            return false;
        }

        final String packageName = node.getPackageName() != null ?
                node.getPackageName().toString() : "";
        return packageName.startsWith("org.mozilla.");
    }
}
